#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@NAME          : baidupan.py
@TIME          : 2025/02/12 20:19:59
@AUTHOR        : chenlip
@VERSION       : 0.0.1
@DESCRIPTION   : 百度网盘图片文件夹列表和文件处理
    前提：
        请先安装百度网盘客户端SDK，（E:\python\baidu.pan） 
            1- 进入你所在的目录，激活项目的虚拟环境
            2- 切换到SDK目录下，
            3- 通过命令行执行：pip install bdopenapi_cli-0.1-py3-none-any.whl
        配置登录账号，获取refresh_token
'''
# --------------------------引用--------------------------
import os, sys, json, time, random
from datetime import datetime, timedelta
import requests

from app.conf.config import conf
from app.common.utils import Utils as ut

from openapi_client.app.auth import BaiduPanAuth
from openapi_client.app.fileinfo import FileInfo
from openapi_client.app.filemanager import FileManager
from openapi_client.app.multimedia_file import MultiMediaFile
# --------------------------引用--------------------------

class BaiduPanPic:
    # 百度网盘相关验证配置, BAIDUCONF包含appid, auth_code, client_id, client_secret, sign_key, access_token, refresh_token
    BAIDUCONF = conf['dev']().BAIDUPAN
    TOKEN_JSON_FN = BAIDUCONF['token_json_fn']

    def __init__(self, temp_dir: str = None):
        self.access_token = self.check_token_expiry()

        self.file_info = FileInfo(self.access_token)
        self.multimedia_file = MultiMediaFile(self.access_token)
        self.file_manager = FileManager(self.access_token)
        self.per_num = 500 # 分页，每页数量

        # 设置本地的临时目录
        temp_dir = os.path.join(temp_dir, ut.CURDAY) if temp_dir else os.path.join(ut.TEMPDIR, ut.CURDAY) # 如果未指定临时目录，则默认为static/temps/当天日期
        os.makedirs(temp_dir, exist_ok=True)

        # 设置 json 文件存放目录
        self.json_dir = os.path.join(os.path.dirname(temp_dir), 'bd_json')
        os.makedirs(self.json_dir, exist_ok=True)

        self.temp_dir = temp_dir

        # 文件信息数据集合,1000个文件=500k；60*500=30000k
        self.file_info_set = {}

    # 检查百度网盘的token是否过期
    def check_token_expiry(self):
        '''
            检查百度网盘的token是否过期， token保存文件TOKEN_JSON_FN
            程序逻辑：
            1- 检查TOKEN_JSON_FN文件是否存在，如果不存在，则创建一个空的文件
            :Return: bool
        '''
        _bdauth = BaiduPanAuth(self.BAIDUCONF['auth_code'], self.BAIDUCONF['client_id'], self.BAIDUCONF['client_secret'])
        
        if not os.path.exists(self.TOKEN_JSON_FN):
            
            _token_res = _bdauth.oauthtoken_authorizationcode() # 根据授权码获取Access Token
            print(f"获取token结果：{_token_res}")

            _t_dict = json.loads(_token_res.replace("'",'"'))
            # 计算过期时间，当前时间加上29天
            _expire_date = (datetime.now() + timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S')
            _t_dict['expire_date'] = _expire_date
            
            with open(self.TOKEN_JSON_FN, 'w') as f:
                f.write(json.dumps(_t_dict))
        
        token_data = {}
        
        with open(self.TOKEN_JSON_FN, 'r') as f:
            token_data = json.load(f)
            _access_token = token_data.get('access_token')
            _refresh_token = token_data.get('refresh_token')
            _expire_date = token_data.get('expire_date')
            _expire_date = datetime.strptime(_expire_date, '%Y-%m-%d %H:%M:%S')

            if _expire_date > datetime.now() + timedelta(days=2): # 离过期时间大于2天
                print(ut.cco(f"\n token未过期，过期时间：{_expire_date}, 当前时间：{ut.CURTIME} \n","blue"))
                return _access_token
            else:
                # token过期，刷新token
                _token_res = _bdauth.oauthtoken_refreshtoken(_refresh_token) # 根据刷新码获取新Token
                print(f"获取刷新token结果：{_token_res}")
                _t_dict = json.loads(_token_res.replace("'",'"'))
                _access_token = _t_dict['access_token']
                # 计算过期时间，当前时间加上30天
                _expire_date = (datetime.now() + timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S')
                _t_dict['expire_date'] = _expire_date
                with open(self.TOKEN_JSON_FN, 'w') as f:
                    f.write(json.dumps(_t_dict))
        
        return _access_token

    # 创建百度网盘目录
    def create_dir(self, dirpath: str):
        _re = self.file_manager.create_dir(dirpath)
        return _re
    
    # 移动文件到指定目录
    def move_file(self, filepath: str, newpath: str):
        '''
            移动文件到指定目录;重名就覆盖；
            :Args:
                - filepath: str, 文件路径, '/test/123456.docx'
                - newpath: str, 新路径, '/test/abc'
            
            {
                    "filelist": str, 文件列表, '[{"path":"/test/123456.docx","dest":"/test/abc","newname":"123456.docx","ondup":"overwrite"}]'
                    "ondup": str,  (optional), 默认为overwrite
                }
        '''
        _f_lst = [
            {
            "path": filepath,
            "dest": newpath,
            "newname": os.path.basename(filepath),
            "ondup": "overwrite"
        }
        ]
        _f_dict = {
            "filelist": json.dumps(_f_lst),
            "ondup": "overwrite"
        }

        _re = self.file_manager.move(_f_dict)

        if _re != None and _re.get('errno') == 0:
            print(ut.cco(f"移动文件 {filepath} 到 {newpath} 成功", 'green'))
        else:
            print(ut.cco(f"移动文件 {filepath} 到 {newpath} 失败: {_re}", 'red'))

        return _re

    # 获取某个目录下的所有图片
    def get_images_list(self, albumpath: str, pagenum:tuple = None):
        '''测试获取默认相册下的所有文件并转换格式, pagenum为None时，获取所有图片数量(其实最多也就是1000个)'''
        if pagenum:
            _req_data = {
                "parent_path": albumpath,
                "recursion": "0",
                "desc": "1",
                "page": pagenum[0],
                "num": pagenum[1],
                "web": "0"
            }
        else:
            _req_data = {
                "parent_path": albumpath,
                "recursion": "0",
                "desc": "1",
                "web": "0"
            }
        
        _res = self.file_info.imagelist(_req_data)
        if _res['errno'] != 0 or len(_res['info'])==0:
            print(ut.cco(f"获取 {albumpath} 图片列表失败：请检查图片路径是否正确, \n {_res}", 'red'))
            return
        else:
            print(f"获取 {albumpath} 图片列表第 {pagenum[0]} 页成功", len(_res['info']))

            converted_list = self.convert_bdimglist(_res)
            return converted_list
        
    def get_images_count(self, albumpath: str, page: int = 1, num: int = 1000, total: int = 0, recursive_count: int = 0):
        '''递归获取默认相册下的所有图片数量
        Args:
            albumpath: 相册路径
            page: 当前页码,默认从1开始
            num: 每页数量,默认1000(百度网盘限制)
            total: 累计总数,用于递归累加
            recursive_count: 递归计数,用于控制暂停
        
        Returns:
            int: 总图片数量
        '''
        # 每递归5次,随机暂停15-20秒
        recursive_count += 1
        if recursive_count % 3 == 0:
            sleep_time = random.randint(15, 20)
            print(f"已递归 {recursive_count} 次,暂停 {sleep_time} 秒...已计数: {total}")
            time.sleep(sleep_time)

        _req_data = {
            "parent_path": albumpath,
            "recursion": "0",
            "desc": "1",
            "page": page,
            "num": num,
            "web": "0"
        }

        _res = self.file_info.imagelist(_req_data)
        _errno = int(_res['errno'])
        if _errno == 31034:
            print(ut.cco(f"递归获取 {albumpath} 下的所有图片数量：请求太快了, 暂停8秒再次请求； \n {_res}", 'red'))
            time.sleep(8)
            _res = self.file_info.imagelist(_req_data)
            
        
        # 如果当前页返回的数量等于num,说明可能还有下一页
        _current_count = len(_res['info'])
        total += _current_count
        
        if _current_count == num:
            # 递归获取下一页,传入递归计数
            time.sleep(5)
            return self.get_images_count(albumpath, page + 1, num, total, recursive_count)
        else:
            return total
        

    def get_images_count_v1(self, albumpath: str, num: int = 1000) -> int:
        """获取指定相册路径下的所有图片数量
        
        Args:
            albumpath: 相册路径
            num: 每页请求数量(最大值1000，百度网盘限制)
        
        Returns:
            总图片数量
            
        Raises:
            RuntimeError: 当连续请求失败时抛出异常
        """
        total = 0
        page = 1
        request_counter = 0
        max_retries = 3  # 最大重试次数
        num = min(num, 1000)  # 确保符合API限制

        while True:
            request_counter += 1
            current_count = 0
            retries = 0

            # 请求频率控制
            if request_counter % 3 == 0:
                sleep_time = random.randint(15, 20)
                print(f"安全暂停：已请求 {request_counter} 次，暂停 {sleep_time} 秒...")
                time.sleep(sleep_time)

            # 构造请求参数
            req_data = {
                "parent_path": albumpath,
                "recursion": "0",
                "desc": "1",
                "page": page,
                "num": num,
                "web": "0"
            }

            # 带重试机制的请求
            while retries < max_retries:
                try:
                    res = self.file_info.imagelist(req_data)
                    errno = int(res.get('errno', 0))
                    
                    if errno == 31034:
                        print(f"请求过频，第 {retries+1} 次重试...")
                        time.sleep(8 + 2 * retries)  # 退避策略
                        retries += 1
                        continue
                    
                    if errno != 0:
                        raise RuntimeError(f"API返回错误: {res}")
                    
                    current_count = len(res.get('info', []))
                    total += current_count
                    break  # 请求成功时退出重试循环

                except (KeyError, TypeError) as e:
                    print(f"数据解析异常: {str(e)}")
                    retries += 1
                    time.sleep(5)
            else:
                raise RuntimeError(f"while 连续请求失败 {max_retries} 次，终止操作")

            # 判断是否继续翻页
            if current_count < num:
                break

            page += 1
            time.sleep(5)  # 正常翻页间隔

        return total

    # 根据文件总数和每页数量计算总页数
    def cal_total_pages(self, total_files: int, files_per_page: int=1000) -> int:
        '''计算总页数和最后一页的文件数量'''
        _total_pages = (total_files + files_per_page - 1) // files_per_page
        
        return _total_pages
        
    # 对中文路径进行编码
    def encode_path(self, path: str) -> str:
        '''对中文路径进行URL编码'''
        # 判断是否包含中文
        def has_chinese(text):
            return any('\u4e00' <= char <= '\u9fff' for char in text)

        if not has_chinese(path):
            return path
        else:
            path = ut.urlquote(path)
        
        return path
    
    # 获取目录结构列表
    def get_dir_list(self, parent_path: str = None, deep: int = 0):
        '''
            获取目录结构列表
            Args:
                parent_path: str 父目录路径

        '''
        _dir_list = []
        _req_data = {
            "dir": parent_path,
            "folder": "1",
            "start": "0",
            "limit": 1000,
            "order": "time",
            "desc": 1,
            "web": "0",
            "showempty": 1
        }
        _res = self.file_info.filelist(_req_data)
        if (_res['errno'] != 0 or len(_res['list'])==0):
            print(ut.cco(f"获取目录列表失败：请检查目录路径是否正确", 'red'))
            return None
        else:
            print(f"获取目录 {parent_path} 列表成功, 共有目录数：", len(_res['list']))
            _n = 0
            for item in _res['list']:
                _n += 1
                # 每循环5次，随机休眠10-15秒
                if _n % 5 == 0:
                    _s = random.randint(10, 15)
                    print(f"每获取5个目录后，休眠10-15; {_s} 秒")
                    time.sleep(_s)
                
                if '/RedmiK30/news_article_lite/news_article' == item['path']:
                    _f_count = 57230
                else:
                    _f_count = self.get_images_count(item['path'])

                _f_pages = self.cal_total_pages(_f_count, self.per_num)

                print(f"目录：{item['path']}，图片数量：{_f_count}，分页总数：{_f_pages}, 每页数量：{self.per_num}")

                _dir_list.append([item['path'], _f_count, _f_pages, self.per_num])
            
            return _dir_list

    # 写入记录目录列表
    def w_r_dir_list(self, imgpath: str):
        '''写入记录目录列表
        Args:
            imgpath: str 图片文件夹路径
        
        Returns:
            list: 目录信息列表, [[目录路径, 图片数量, 分页总数, 每页数量], ...]
        '''
        _lst_txt = os.path.join(os.path.dirname(self.temp_dir), "bd_news_article_dir_list.txt")
        if os.path.exists(_lst_txt):
            _d_temp_lst = ut.r_w_lst_txt('r', _lst_txt)
            print(f"目录信息列表 已读取到列表, \n {_d_temp_lst}")
        else:
            _d_temp_lst = self.get_dir_list(imgpath)
            _lst_txt = ut.r_w_lst_txt('w', _lst_txt, _d_temp_lst)
            print(f"目录信息列表 已写入到: {_lst_txt}")
        
        return _d_temp_lst

    # 生成某个目录下的所有文件列表到txt文件中。
    def gen_img_lst_txt(self, dirpath: str, totalpage:int) -> list:
        '''
        获取指定目录下,按分页获取所有文件列表，形成一个json文件，用于后续处理
        json文件名： 使用目录名+页码.json
            :Args:
                dirpath: str 图片文件夹路径
                totalpage: int 分页总数
                num: int 每页数量

            :Returns:
                list: 文件信息列表写入到txt文件中；[[file_id, path, fname, ext, size, box, created_at, updated_at, local_created_at, local_modified_at], ...]
        '''
        _file_list = []
        
        for page in range(1, int(totalpage) + 1):
            _file_list = self.get_images_list(dirpath, (page, self.per_num))

            # 每获取2页，随机休眠5-10秒
            if page % 3 == 0:
                _s = random.randint(5, 10)
                print(f"按分页获取所有文件列表，形成一个json文件；每获取2页后，休眠5-10秒; {_s} 秒")
                time.sleep(_s)
            
            _txt_fn = os.path.join(self.json_dir, f"{os.path.basename(dirpath)}_{str(page)}.txt")
            
            _txtlst = ut.r_w_lst_txt('w', _txt_fn, _file_list)

            print(f"==== 文件信息列表 已写入到: {_txt_fn}")

        return _txtlst
    
    def _binary_search(self, files: list, target_name: str) -> dict:
        """二分查找文件信息
        Args:
            files: 排序后的文件列表
            target_name: 要查找的文件名
        Returns:
            dict: 文件信息字典,未找到返回None
        """
        if not files:
            return None
            
        left, right = 0, len(files) - 1
        
        while left <= right:
            mid = (left + right) // 2
            mid_name = files[mid]['name']
            
            if mid_name == target_name:
                return files[mid]
            elif mid_name < target_name:
                left = mid + 1
            else:
                right = mid - 1
                
        return None
    
    def _load_and_sort_files(self, dir_name: str):
        """只在第一次加载时进行排序并创建索引"""
        _temp_fs = []
        txt_files = [f for f in os.listdir(self.json_dir) if f.startswith(dir_name) and f.endswith('.txt')]
        
        for txt_file in txt_files:
            file_path = os.path.join(self.json_dir, txt_file)
            file_list = ut.r_w_lst_txt('r', file_path)
            if file_list:
                _temp_fs.extend(file_list)

        # 使用sorted而不是sort，通常更快
        return sorted(_temp_fs, key=lambda x: x['name'])

    # 根据文件夹名称和文件名称;从本地的txt文件中搜索文件信息
    def get_file_info_by_name(self, dir_name: str, f_name: str):
        '''根据文件夹名和文件名从本地txt文件中搜索文件信息
        Args:
            dir_name: str 文件夹名称，例如：abc
            f_name: str 文件名，例如：adb.jpg
        Returns:
            dict: 文件信息字典
        '''
        index_file = os.path.join(self.json_dir, f"sorted_index_{dir_name}.txt")
        all_files = self.file_info_set.get(dir_name)

        if all_files is None:
            if os.path.exists(index_file):
                # 直接读取已排序的索引文件，如果对应目录下的文件发生变化，则手动删除索引文件；
                all_files = ut.r_w_lst_txt('r', index_file)
                self.file_info_set[dir_name]= all_files
            else:
                # 第一次需要排序并保存索引
                all_files = self._load_and_sort_files(dir_name)
                ut.r_w_lst_txt("w", index_file, all_files)

                self.file_info_set[dir_name]= all_files
        
        # 在合并后的列表中查找目标文件
        file_info = self._binary_search(all_files, f_name)

        return file_info

    # 获取默认相册下的所有文件
    def convert_bdimglist(self, res_dict: dict) -> list:
        '''
        将百度网盘返回的图片列表转换为标准格式
        Args:
            json_str: dict 百度网盘返回的JSON字符串,
        Returns:
            list: 转换后的图片信息列表
        '''
        result = []
        
        for item in res_dict['info']:
            # 从路径中提取文件名和扩展名
            filename = item['server_filename']
            file_extension = filename.split('.')[-1] if '.' in filename else ''
            
            # 使用md5和fs_id生成唯一的file_id
            file_id = item['fs_id']

            converted_item = ut.BASE_IMAGE_FILE.copy()
            # _f_d = self.get_file_detail(str(file_id))
            converted_item['file_id'] = file_id
            converted_item['path'] = os.path.dirname(item['path'])
            converted_item['fname'] = filename
            converted_item['ext'] = file_extension
            converted_item['size'] = item['size']
            # converted_item['box'] = [_f_d[1], _f_d[2]]
            converted_item['created_at'] = ut.unixtime_localtime(item['server_ctime'])
            converted_item['updated_at'] = ut.unixtime_localtime(item['server_mtime'])
            converted_item['local_created_at'] = ut.unixtime_localtime(item['local_ctime'])
            converted_item['local_modified_at'] = ut.unixtime_localtime(item['local_mtime'])
            
            result.append(converted_item)
        
        return result

    def get_file_detail(self, fsid: str):
        '''获取图像和视频文件详细信息'''
        _req_dict = {
            "fsids": "[" + fsid + "]",
            "thumb": "0",
            "extra": "1",
            "dlink": "1"
        }
        _res = self.multimedia_file.filemetas(_req_dict)
        # pprint(_res)
        if _res["errno"] != 0:
            print(f"获取文件详细信息失败：{fsid}")
            return ("No", 0, 0)
        
        if not _res["list"]:
            print(f"未找到文件详细信息：{fsid}")
            return ("No", 0, 0)

        _dlink = _res["list"][0]["dlink"]
        _width = _res["list"][0]["width"]
        _height = _res["list"][0]["height"]

        return (_dlink, _width, _height)
    
    def dl_file(self, fsid:str):
        ''' 下载文件 '''
        return self.file_manager.download(fsid, self.temp_dir)

